PHP Segmentation fault 问题排查笔记

PHP Segmentation fault 问题排查笔记

1.问题征兆

​ 国庆前收到报警通知 , 线上数据同步脚本容器容量使用超过200G,并以很快的速度增加,查看线上监控,每隔10分钟阶梯性增长数百MB,一天平均增加20G左右

1
【报警集群】prod集群【报错信息】Pod website-newstagger-cron-789668bc64-85qgw的容器website-newstagger-cron-pod容量使用超过了200G(当前值221.97G) [Level1 critical]

2.初步排查

​ 由于线上项目已经发布将近一个月,且期间除了已知的一些问题邮件报警外并没有发现其他问题,业务方使用也很正常,因此最开始判读是线上日志积累的太多,但是联系运维删除运行日志后,发现容量仅减少了几个G,由于线上容器没有直接操作权限,开始在测试环境排查

​ 查看测试环境容器监控后,发现测试环境容量虽然没有用到200G,但也有80多G,使用du命令查找后发现容量全部在root目录下

1
2
# 用到的du命令
du -h -s /* | sort -rn | head

​ 进入root目录后,执行ls 发现root目录下有大量的 core.数字core.885 , 文件大小只有100兆左右的和20兆左右的,就是这些文件占用的99%的存储空间.此时我并不知道这个文件是因为什么产生,也不知道core文件是什么,直接执行cat 选择一个比较小的查看后,满屏的乱码刷了几十秒,期间夹杂着一些中文似乎是某个接口的返回值,然后我就去找搜索引擎请教core文件是什么了。

很快我找到一篇文章

[Core文件作用、设置及用法]

Core文件其实就是内存的映像,当程序崩溃时,存储内存的相应信息,主用用于对程序进行调试。当程序崩溃时便会产生core文件,其实准确的应该说是core dump 文件,默认生成位置与可执行程序位于同一目录下,文件名为core.***,其中***是某一数字。

​ 初步查看了一下 ,目测离找到问题根源还比较遥远,由于手头任务还比较多,且马上就要为祖国母亲庆生,这种激动人心的时刻,怎么能静下心查bug呢?再加上项目已经稳定运行了20多天 ,戒指这个问题除了占用内存外应该不会产生什么大的影响,在测试环境执行删除命令后,容量回复正常,不到1G,然后我联系了运维删除了线上容器的的core文件,很幸运的是我平安的度过了祖国母亲的生日

1
2
# 删除文件命令
rm -rf core*

3.复现问题

​ 国庆回来后仅过了一天,熟悉的报警再次来临

1
【报警集群】prod集群【报错信息】Pod website-newstagger-cron-789668bc64-85qgw的容器website-newstagger-cron-pod容量使用超过了200G(当前值200.32G) [Level1 critical]

​ 是时候找到真正的原因了,首先需要让问题复现。

​ 在之前的那篇文章中,我已经了解到gdb命令可以用来分析core dump文件,再次请教搜索引擎,初步学习下gdb的用法

1
gdb [exec file] [core file]

​ 然后执行:

1
gdb php core.885

​ 在很多的信息中我注意到了这些

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Reading symbols from /lib64/libdb-4.7.so...(no debugging symbols found)...done.
Loaded symbols for /lib64/libdb-4.7.so

warning: no loadable sections found in added symbol-file system-supplied DSO at 0x7ffcc25fa000
Core was generated by `/usr/local/bin/php /var/www/html/newstagger/bin/light-cli tagger/Stock/thread -'.
Program terminated with signal 11, Segmentation fault.
#0 match (
eptr=0x7615273 "00,\\\"负面资讯风险类别[20190909-20191009]\\\":\\\"破产重整预警\\\",\\\"负面资讯来源[20190909-20191009]\\\":\\\"新浪财经\\\",\\\"负面资讯重要性[20190909-20191009]\\\":2,\\\"负面资讯链接"..., ecode=0x318dd07 "\035\"x",
mstart=0x76111ca "\"[{\\\"负面资讯标题[20190909-20191009]\\\":\\\"*ST盐湖:涉及诉讼请求标的金额合计为3.82亿元\\\",\\\"负面资讯时间[20190909-20191009]\\\":\\\"20190909\\\",\\\"负面资讯获取时间[2019090"..., offset_top=4, md=0x7ffcc258de10, eptrb=0x0, rdepth=15847)
at /usr/local/src/php-5.6.3/ext/pcre/pcrelib/pcre_exec.c:516
516 /usr/local/src/php-5.6.3/ext/pcre/pcrelib/pcre_exec.c: No such file or directory.
in /usr/local/src/php-5.6.3/ext/pcre/pcrelib/pcre_exec.c
Missing separate debuginfos, use:
debuginfo-install cyrus-sasl-lib-2.1.23-15.el6_6.2.x86_64 db4-4.7.25-20.el6_7.x86_64
freetype-2.3.11-17.el6.x86_64 glibc-2.12-1.209.el6_9.2.x86_64 keyutils-libs-1.4-5.el6.x86_64
krb5-libs-1.10.3-65.el6.x86_64 libcom_err-1.41.12-23.el6.x86_64 libcurl-7.19.7-53.el6_9.x86_64
libidn-1.18-2.el6.x86_64 libjpeg-turbo-1.2.1-3.el6_5.x86_64 libpng-1.2.49-2.el6_7.x86_64
libselinux-2.0.94-7.el6.x86_64 libssh2-1.4.2-2.el6_7.1.x86_64 l

​ 程序似乎是在执行db相关操作的时候崩溃的,再使用gdb bt命令 ,发现程序时在访问0x7ffcc258de10地址时出错了,又用同样的方法分析了几个其他的core dump 文件 错误都是一样的,同时我还有新的发现:

测试环境的core dump文件只有两个大小,109MB和19MB,同样大小的文件中文的部分也是一致的

简单介绍下我的程序的逻辑:

​ 首先通过corntab 每分钟启动一个manager进程, manager进程会判断数据库中有多少条问句需要更新,然后启动一批thread进程去请求问句接口来更新该问句的结果,每条问句会有几十到几百上千条不同的信息,这些信息需要关联到不同的 标的 并写入到数据库中

程序的每个问句的结果是不一样的,从core dump 文件的分析结果来看 ,应该是其中两条问句的进程在执行过程中出现了问题,同时根据前面Loaded symbols for /lib64/libdb-4.7.so,可以推测是在请求数据库的时候出现了异常,找到这两个结果对应的问句后,单独执行这两个问句的进程,问题复现了

4. 追根溯源

复现问题后,通过简单的断点测试后,我定位到了业务代码中程序奔溃的地方

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 一次插入多条数据
* 使用此方法时请注意不要让sql语句的大小超过mysql MAX_ALLOWED_PACKET 的限制
* @param string $table 表名
* @param array $data 数据组 二维数组,已第一条数据的键值作为字段名插入数据库
* @return int
* @throws Exception
*/
public function insertAll($table, $data)
{
if (!$data) {
return 0;
}

$sql = 'insert into ' . $table . $this->getField($data) . ' VALUES ' . $this->getValues($data);
return $this->getAdapter()->query($sql)->rowCount();
}

进程是在执行$this->getAdapter()->query($sql)时崩溃的,之后的代码就是zend framework的部分了,首先检查此时生成的SQL语句,将它保存到文本中查看,文件很长,即使只保存一条数据也有27K的大小,凭借肉眼去判断SQL太难了,因此直接使用mysql命令执行保存的SQL 文件,出乎意料的sql执行成功了~

insertAll 依赖代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
   
/**
* 分批插入多条数据 可指定每次插入的最大条数 避免insertAll方法生成的SQL语句过大超出MAX_ALLOWED_PACKET限制
*
* @param string $table
* @param array $data
* @param int $num
* @return int
*/
public function insertAllBatch($table, $data, $num = 500)
{
$chunkData = array_chunk($data, $num);
$resNum = 0;

try {
foreach ($chunkData as $item) {
$resNum += $this->insertAll($table, $item);
}
return $resNum;
} catch (Exception $exception) {
$msg = date('Y-m-d H:i:s') . ' ' . $exception->getMessage() . "\n";
file_put_contents('/tmp/logs/newstagger/error.log', $msg, FILE_APPEND);
// $this->getAdapter()->rollBack();
return $resNum;
}
}

/**
* 获取多条数据插入时的字段名
*
* @param array $data
* @return string
* @throws Exception
*/
public function getField($data)
{
if (isset(array_keys($data)[0])) {
$key = array_keys($data)[0];
return '(`' . implode('`,`', array_keys($data[$key])) . '`)';
} else {
throw new Exception('插入多条数据时,传入的数据格式有误!');
}
}

/**
* 组合多条数据同时插入时
*
* @param array $data
* @return string
*/
public function getValues($data)
{
$val = '';
foreach ($data as $item) {
$val .= '(';
foreach ($item as $value) {
// 拼接value 并转义
$val .= "'" . addslashes($value) . "',";
}
$val = rtrim($val, ', ');
$val .= '),';
}
$val = rtrim($val, ',');
return $val;
}

下面只能继续向框架层去挖掘了 , 首先进入query方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/**
* Prepares and executes an SQL statement with bound data.
*
* @param mixed $sql The SQL statement with placeholders.
* May be a string or Zend_Db_Select.
* @param mixed $bind An array of data to bind to the placeholders.
* @return Zend_Db_Statement_Interface
*/
public function query($sql, $bind = array())
{
// connect to the database if needed
$this->_connect();

// is the $sql a Zend_Db_Select object?
if ($sql instanceof Zend_Db_Select) {
if (empty($bind)) {
$bind = $sql->getBind();
}

$sql = $sql->assemble();
}

// make sure $bind to an array;
// don't use (array) typecasting because
// because $bind may be a Zend_Db_Expr object
if (!is_array($bind)) {
$bind = array($bind);
}

// prepare and execute the statement with profiling
$stmt = $this->prepare($sql);
$stmt->execute($bind);

// return the results embedded in the prepared statement object
$stmt->setFetchMode($this->_fetchMode);
return $stmt;
}

经过断点测试,在执行到$stmt = $this->prepare($sql);时进程崩溃,查看prepare()方法:

通过IDE的代码跳转,我跳转到了这里,然后IDE就不能继续跳转了

1
abstract public fucntion prepare($sql);

经过观察后,发现这个目录同级还有一些Mysqli.php , Oracle.php文件,进入Mysqli.php后,果然找到了prepare方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* Prepare a statement and return a PDOStatement-like object.
*
* @param string $sql SQL query
* @return Zend_Db_Statement_Mysqli
*/
public function prepare($sql)
{
$this->_connect();
if ($this->_stmt) {
$this->_stmt->close();
}
$stmtClass = $this->_defaultStmtClass;
if (!class_exists($stmtClass)) {
require_once 'Zend/Loader.php';
Zend_Loader::loadClass($stmtClass);
}
$stmt = new $stmtClass($this, $sql);
if ($stmt === false) {
return false;
}
$stmt->setFetchMode($this->_fetchMode);
$this->_stmt = $stmt;
return $stmt;
}

通过打印$stmtClass我得到了下一个目标Zend_Db_Statement ,它的构造函数如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* Constructor for a statement.
*
* @param Zend_Db_Adapter_Abstract $adapter
* @param mixed $sql Either a string or Zend_Db_Select.
*/
public function __construct($adapter, $sql)
{
$this->_adapter = $adapter;
if ($sql instanceof Zend_Db_Select) {
$sql = $sql->assemble();
}
$this->_parseParameters($sql);
$this->_prepare($sql);

$this->_queryId = $this->_adapter->getProfiler()->queryStart($sql);
}

同样的断点测试,定位到了$this->_parseParameters($sql);

_parseParameters方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/**
* @param string $sql
* @return void
*/
protected function _parseParameters($sql)
{
$sql = $this->_stripQuoted($sql);

// split into text and params
$this->_sqlSplit = preg_split('/(\?|\:[a-zA-Z0-9_]+)/',
$sql, -1, PREG_SPLIT_DELIM_CAPTURE|PREG_SPLIT_NO_EMPTY);

// map params
$this->_sqlParam = array();
foreach ($this->_sqlSplit as $key => $val) {
if ($val == '?') {
if ($this->_adapter->supportsParameters('positional') === false) {
/**
* @see Zend_Db_Statement_Exception
*/
require_once 'Zend/Db/Statement/Exception.php';
throw new Zend_Db_Statement_Exception("Invalid bind-variable position '$val'");
}
} else if ($val[0] == ':') {
if ($this->_adapter->supportsParameters('named') === false) {
/**
* @see Zend_Db_Statement_Exception
*/
require_once 'Zend/Db/Statement/Exception.php';
throw new Zend_Db_Statement_Exception("Invalid bind-variable name '$val'");
}
}
$this->_sqlParam[] = $val;
}

// set up for binding
$this->_bindParam = array();
}

这次很快我们就定位到了第一行的$sql = $this->_stripQuoted($sql);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/**
* Remove parts of a SQL string that contain quoted strings
* of values or identifiers.
*
* @param string $sql
* @return string
*/
protected function _stripQuoted($sql)
{

// get the character for value quoting
// this should be '
$q = $this->_adapter->quote('a');
$q = $q[0];
// get the value used as an escaped quote,
// e.g. \' or ''
$qe = $this->_adapter->quote($q);
$qe = substr($qe, 1, 2);
$qe = preg_quote($qe);
$escapeChar = substr($qe,0,1);
// remove 'foo\'bar'
if (!empty($q)) {
$escapeChar = preg_quote($escapeChar);
// this segfaults only after 65,000 characters instead of 9,000
$sql = preg_replace("/$q([^$q{$escapeChar}]*|($qe)*)*$q/s", '', $sql);
}

// get a version of the SQL statement with all quoted
// values and delimited identifiers stripped out
// remove "foo\"bar"
$sql = preg_replace("/\"(\\\\\"|[^\"])*\"/Us", '', $sql);

// get the character for delimited id quotes,
// this is usually " but in MySQL is
$d = $this->_adapter->quoteIdentifier('a');
$d = $d[0];
// get the value used as an escaped delimited id quote,
// e.g. \" or "" or \`
$de = $this->_adapter->quoteIdentifier($d);
$de = substr($de, 1, 2);
$de = preg_quote($de);
// Note: $de and $d where never used..., now they are:
$sql = preg_replace("/$d($de|\\\\{2}|[^$d])*$d/Us", '', $sql);
return $sql;
}

这次,我们终于要接近问题的真相了,程序在执行到$sql = preg_replace("/\"(\\\\\"|[^\"])*\"/Us", '', $sql);时崩溃,而preg_replace是一个php 的系统函数

我将前面保存的sql语句直接传入

再次请教搜索引擎,preg_replace Segmentation fault 我得到了以下地址:

https://stackoverflow.com/questions/20750757/php-segmentation-fault-during-preg-replace

https://bugs.php.net/bug.php?id=61579

和我的遭遇类似,这是一个由于低效正则在替换长文本是由于超过最大递归深度引发的内存溢出问题,属于PHP5.6版本中底层源码的bug

之后我一度陷入僵局,问题已经定位到,不过如果替换掉框架内的这行代码,鬼知道会不会引发什么其他的问题,升级php版本的工作量也不少,有什么简单的方法可以解决吗?

幸运的是,同事给了我一份他曾经写过的insertAll方法的源码,通过的它的方法,并没有触发这个bug,经过分析,我发现他的代码中没有对特殊字符进行转义,只是使用单引号将值包了起来,而我使用了addslashes这个函数进行了转义,由于要写入数据库的是请求接口结果的json数据,因此转义时产生了大量的\" , 而这极大的影响了 preg_replace 的效率 因此,我手工实现了一个转义函数,通过字符实体替换反斜杠转义,成功规避问题

1
2
3
4
5
6
public fucntion stringEscape($value){
$value = str_replace("'", '&apos', $value);
$value = str_replace('"', '&quot', $value);
$value = str_replace('\\', '\\\\', $value);
return $value;
}